SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
23.7 KB · 412 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { PageHeader, Section, KV, Note } from '@/components/ui/section';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';8import { SourceBadge } from '@/components/ui/source-badge';9import { Breadcrumbs } from '@/components/layout/breadcrumbs';10import { CountryActivity } from '@/components/country/activity';11import { TrendChart, type TrendSeries } from '@/components/charts/trend-chart';12import { getGeographyBySlug, coverageFor, yearsFor, allSitesObservations, topCancersFor, trendFor, geographyScopeCode, WHO_REGION_LABEL, SEXES, BURDEN_METRICS, type Sex, type TopCancersResult } from '@/lib/queries/geography';13import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';14import { loadProvenance, toInfo } from '@/lib/queries/provenance';15import { listSources } from '@/lib/queries/sources';16import { jsonLd } from '@/lib/seo';17import { SITE_URL, SITE_NAME } from '@/lib/site';18import { str, int, oneOf, type SP } from '@/lib/search-params';19import { fmtInt, fmtValue, humanize, toDate, unitLabel } from '@/lib/format';2021export const revalidate = 3600;2223type Params = { slug: string };2425export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {26  const geo = await getGeographyBySlug((await params).slug);27  if (!geo) return { title: 'Not found' };28  return {29    title: `${geo.name} — cancer statistics`,30    description: `Cancer incidence and mortality observations for ${geo.name}: top cancers by annual deaths, new cases and age-standardized rates per year and sex, with trends and sources.`,31    alternates: { canonical: `/country/${geo.slug}` },32  };33}3435/**36 * /country/[slug] (§47): header, latest-year summary, top-cancer tables per metric with sex/year selectors,37 * a multi-line trend of the top 8 cancers' age-standardized mortality, and a sources/freshness block.38 * Every value comes from epidemiology_observations; ranks from the matching ranking snapshot when it exists.39 */40export default async function CountryPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {41  const { slug } = await params;42  const sp = await searchParams;43  const geo = await getGeographyBySlug(slug);44  if (!geo) notFound();45  const [coverage, years, sources] = await Promise.all([coverageFor(geo.id), yearsFor(geo.id), listSources()]);46  const latestYear = years[0];47  const sex = oneOf<Sex>(sp, 'sex', SEXES, 'all');48  const year = latestYear ? int(sp, 'year', latestYear, years[years.length - 1]!, latestYear) : null;49  const scopeCode = geographyScopeCode(geo);5051  const crumbs = [{ label: 'Countries', href: '/countries' }, ...(geo.parent_slug && geo.parent_name && geo.kind === 'subdivision' ? [{ label: geo.parent_name, href: `/country/${geo.parent_slug}` }] : []), { label: geo.name }];5253  if (!latestYear || year == null) {54    return (55      <div>56        <Breadcrumbs items={crumbs} className="pt-5" />57        <PageHeader kicker={humanize(geo.kind)} title={geo.name} lede={headerLede(geo)} />58        <EmptyState title="No epidemiology observation for this geography yet">59          Country statistics appear once a licensed registry connector has ingested observations for {geo.name}. IARC / GLOBOCAN (185 countries) is under license review and the SEER API awaits credentials.60          <div className="mt-1">61            <Link className="ci-link" href="/countries">62              Geographies with data63            </Link>64          </div>65        </EmptyState>66        <div className="mt-8 space-y-8">67          <CountryActivity iso2={geo.iso2} iso3={geo.iso3} name={geo.name} kind={geo.kind} />68        </div>69      </div>70    );71  }7273  const [allSites, ...tops] = await Promise.all([allSitesObservations(geo.id, year, sex), ...BURDEN_METRICS.map((m) => topCancersFor(geo, m, year, sex, 40))]);74  const byMetric = new Map(tops.map((t) => [t.metric, t]));75  const asmr = byMetric.get('as_mortality_rate');76  const trendIds = (asmr?.rows.length ? asmr : byMetric.get('mortality_count'))?.rows.slice(0, 8).map((r) => r.cancer_id) ?? [];77  const trendMetric = asmr?.rows.length ? 'as_mortality_rate' : 'mortality_count';78  const trend = await trendFor(geo.id, trendMetric, sex, trendIds);79  const provIds = tops.flatMap((t) => t.rows.slice(0, 1).map((r) => r.provenance_id));80  const prov = await loadProvenance([...provIds, ...allSites.map((a) => a.provenance_id)]);8182  const usedSources = [...new Set(coverage.map((c) => c.source_slug))].map((s) => sources.find((x) => x.slug === s)).filter((s): s is NonNullable<typeof s> => !!s);83  const freshest = coverage.map((c) => toDate(c.last_updated)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;84  const nCancers = Math.max(...coverage.map((c) => c.n_cancers), 0);85  const estimateTypes = [...new Set(coverage.flatMap((c) => c.estimate_types))];86  const standardPop = coverage.find((c) => c.standard_population)?.standard_population ?? null;8788  const ld = {89    '@context': 'https://schema.org',90    '@type': 'Dataset',91    name: `Cancer incidence and mortality observations — ${geo.name}`,92    description: `Per-site cancer observations for ${geo.name} (${years[years.length - 1]}–${latestYear}): annual deaths, new cases and age-standardized rates by sex, as published by ${usedSources.map((s) => s.name).join('; ') || 'the source registry'}. Normalized by CancerIndex without changing values.`,93    url: `${SITE_URL}/country/${geo.slug}`,94    creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },95    isBasedOn: usedSources.map((s) => s.homepage ?? `${SITE_URL}/source/${s.slug}`),96    spatialCoverage: { '@type': 'Place', name: geo.name, ...(geo.iso3 ? { identifier: geo.iso3 } : {}) },97    temporalCoverage: `${years[years.length - 1]}/${latestYear}`,98    variableMeasured: [...new Set(coverage.map((c) => EPI_METRIC_LABEL[c.metric] ?? c.metric))],99    license: usedSources.map((s) => s.license).filter(Boolean).join('; ') || undefined,100    ...(freshest ? { dateModified: freshest.toISOString() } : {}),101  };102103  const q = (over: Partial<{ sex: string; year: number }>) => {104    const p = new URLSearchParams();105    const s = over.sex ?? sex;106    const y = over.year ?? year;107    if (s !== 'all') p.set('sex', s);108    if (y !== latestYear) p.set('year', String(y));109    const qs = p.toString();110    return `/country/${geo.slug}${qs ? `?${qs}` : ''}`;111  };112113  return (114    <article>115      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(ld) }} />116      <Breadcrumbs items={crumbs} className="pt-5" />117      <PageHeader kicker={humanize(geo.kind)} title={geo.name} lede={headerLede(geo)}>118        <KV119          className="mt-3 max-w-xl"120          items={[121            { k: 'ISO 3166-1', v: geo.iso3 ? <span className="ci-mono">{geo.iso3}{geo.iso2 ? ` · ${geo.iso2}` : ''}</span> : null },122            { k: 'WHO region', v: geo.who_region ? WHO_REGION_LABEL[geo.who_region] ?? geo.who_region : null },123            { k: 'Population', v: geo.population ? <span className="ci-num">{fmtInt(geo.population)}{geo.population_year ? ` (${geo.population_year})` : ''}</span> : <span className="text-ink-3">not seeded — CancerIndex shows only rates and counts published by the source</span> },124            { k: 'Years covered', v: <span className="ci-num">{years[years.length - 1]}–{latestYear}</span> },125            { k: 'Cancer entities', v: <span className="ci-num">{fmtInt(nCancers)}</span> },126            { k: 'Estimate types', v: estimateTypes.map((t) => <Badge key={t} tone={t === 'observed' ? 'ok' : 'warn'} className="mr-1">{t}</Badge>) },127          ]}128        />129      </PageHeader>130131      {/* Selectors: sex + year (server-side URL state) */}132      <nav aria-label="Scope" className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-rule py-2 text-[13px]">133        <div className="flex items-center gap-1.5">134          <span className="ci-kicker mr-1">Sex</span>135          {SEXES.map((s) => (136            <Link key={s} href={q({ sex: s })} aria-current={s === sex ? 'page' : undefined} className={`border px-2 py-0.5 no-underline ${s === sex ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>137              {s === 'all' ? 'Both sexes' : humanize(s)}138            </Link>139          ))}140        </div>141        <div className="flex flex-wrap items-center gap-1.5">142          <span className="ci-kicker mr-1">Year</span>143          {years.slice(0, 8).map((y) => (144            <Link key={y} href={q({ year: y })} aria-current={y === year ? 'page' : undefined} className={`ci-num border px-2 py-0.5 no-underline ${y === year ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>145              {y}146            </Link>147          ))}148          {years.length > 8 ? (149            <details className="relative">150              <summary className="cursor-pointer border border-rule px-2 py-0.5 text-ink-2 hover:border-accent">earlier…</summary>151              <div className="absolute left-0 z-10 mt-1 flex max-w-[320px] flex-wrap gap-1 border border-rule-strong bg-paper p-2 shadow-lg">152                {years.slice(8).map((y) => (153                  <Link key={y} href={q({ year: y })} className={`ci-num border px-2 py-0.5 no-underline ${y === year ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>154                    {y}155                  </Link>156                ))}157              </div>158            </details>159          ) : null}160        </div>161      </nav>162163      {/* Summary cards */}164      <Section id="summary" kicker="Summary" title={`${year} · ${sex === 'all' ? 'both sexes' : humanize(sex)} · all ages`} description="Totals across all cancer sites are shown only when the source publishes an all-sites row. Otherwise the per-site figures below stand on their own; CancerIndex never sums site groups (definitions overlap between sources).">165        {allSites.length ? (166          <ul className="grid grid-cols-1 gap-px bg-rule sm:grid-cols-2 lg:grid-cols-4">167            {allSites.map((a) => (168              <li key={a.metric} className="bg-paper px-3 py-2.5">169                <span className="text-[11.5px] font-medium uppercase tracking-wide text-ink-3">{EPI_METRIC_LABEL[a.metric] ?? a.metric} · all sites</span>170                <span className="mt-0.5 flex items-baseline gap-1.5">171                  <span className="ci-num font-display text-2xl">{fmtValue(a.value, a.unit)}</span>172                  <span className="text-[11.5px] text-ink-3">{unitLabel(a.unit)}</span>173                </span>174                <span className="block text-[11.5px] text-ink-3">175                  {a.year} · {a.site_definition}176                </span>177                <span className="mt-1 flex items-center gap-1.5">178                  <SourceBadge p={toInfo(prov.get(a.provenance_id)) ?? { sourceSlug: a.source_slug, sourceName: a.source_name }} />179                  <ClaimBadge kind="observed" />180                  {a.estimate_type !== 'observed' ? <Badge tone="warn">{a.estimate_type}</Badge> : null}181                </span>182              </li>183            ))}184          </ul>185        ) : (186          <ul className="grid grid-cols-1 gap-px bg-rule sm:grid-cols-2 lg:grid-cols-4">187            {BURDEN_METRICS.map((m) => {188              const t = byMetric.get(m);189              const top = t?.rows[0];190              return (191                <li key={m} className="bg-paper px-3 py-2.5">192                  <span className="text-[11.5px] font-medium uppercase tracking-wide text-ink-3">{EPI_METRIC_LABEL[m] ?? m}</span>193                  {top && t ? (194                    <>195                      <span className="mt-0.5 block text-[12px] text-ink-3">196                        highest site group{t.year !== year ? ` (${t.year}, latest available)` : ''}:197                      </span>198                      <Link href={`/cancer/${top.slug}`} className="ci-link block truncate text-[14px]">199                        {top.canonical_name}200                      </Link>201                      <span className="flex items-baseline gap-1.5">202                        <span className="ci-num font-display text-2xl">{fmtValue(top.value, top.unit)}</span>203                        <span className="text-[11.5px] text-ink-3">{unitLabel(top.unit)}</span>204                      </span>205                      <span className="mt-1 flex flex-wrap items-center gap-1.5">206                        <SourceBadge p={toInfo(prov.get(top.provenance_id)) ?? { sourceSlug: top.source_slug, sourceName: top.source_name }} />207                        <ClaimBadge kind="observed" />208                        <span className="text-[11px] text-ink-3">{fmtInt(t.rows.length)} site groups</span>209                      </span>210                    </>211                  ) : (212                    <span className="mt-1 block text-[12.5px] text-ink-3">no observation for {year}</span>213                  )}214                </li>215              );216            })}217          </ul>218        )}219        {!allSites.length ? <p className="mt-2 text-[12px] text-ink-3">No all-sites total in source for this scope — per-site figures below; no national total is computed.</p> : null}220      </Section>221222      {/* Top tables */}223      {tops.map((t) => (224        <TopTable key={t.metric} t={t} year={year} sex={sex} scopeCode={scopeCode} prov={prov} geoSlug={geo.slug} />225      ))}226227      {/* Trend */}228      <Section id="trend" kicker="Trend" title={`${EPI_METRIC_LABEL[trendMetric]} — top ${Math.min(8, trendIds.length)} cancers, all years`} description={`Top cancers of ${year} (${sex === 'all' ? 'both sexes' : humanize(sex)}) traced over every year published for ${geo.name}. Dashed lines mark estimated or projected values; the y axis starts at zero.`}>229        {trend.length ? (230          <>231            <TrendChart series={toSeries(trend)} unit={trend[0]!.unit} ariaLabel={`${EPI_METRIC_LABEL[trendMetric]} by year for the top ${trendIds.length} cancers in ${geo.name}`} yLabel={unitLabel(trend[0]!.unit)} />232            <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">233              <SourceBadge p={{ sourceSlug: trend[0]!.source_slug }} compact />234              <ClaimBadge kind="observed" />235              {trend[0]!.standard_population ? <span>standard: {trend[0]!.standard_population}</span> : null}236              <span>{fmtInt(trend.length)} points</span>237            </p>238          </>239        ) : (240          <EmptyState compact>No multi-year series for this scope.</EmptyState>241        )}242      </Section>243244      <CountryActivity iso2={geo.iso2} iso3={geo.iso3} name={geo.name} kind={geo.kind} />245246      {/* Sources & freshness */}247      <Section id="sources" kicker="Provenance" title="Sources and freshness" description="What each source covers for this geography, exactly as ingested. Values are normalized (units, labels) but never changed.">248        <div className="ci-table-wrap">249          <table className="ci-table">250            <thead>251              <tr>252                <th>Source</th>253                <th>Metric</th>254                <th>Sex</th>255                <th className="num">Years</th>256                <th className="num">Cancers</th>257                <th>Estimate types</th>258                <th>Standard population</th>259                <th>Ingested</th>260              </tr>261            </thead>262            <tbody>263              {coverage.map((c) => (264                <tr key={`${c.source_slug}-${c.metric}-${c.sex}`}>265                  <td>266                    <SourceBadge p={{ sourceSlug: c.source_slug, sourceName: c.source_name }} compact />267                  </td>268                  <td>{EPI_METRIC_LABEL[c.metric] ?? c.metric}</td>269                  <td>{c.sex === 'all' ? 'Both' : humanize(c.sex)}</td>270                  <td className="num">271                    {c.min_year}–{c.max_year} <span className="text-ink-3">({c.n_years})</span>272                  </td>273                  <td className="num">{fmtInt(c.n_cancers)}</td>274                  <td>275                    {c.estimate_types.map((t) => (276                      <Badge key={t} tone={t === 'observed' ? 'ok' : 'warn'} className="mr-1">277                        {t}278                      </Badge>279                    ))}280                  </td>281                  <td className="text-[12px] text-ink-3">{c.standard_population ?? '—'}</td>282                  <td className="text-[12px] text-ink-3">{toDate(c.last_updated)?.toISOString().slice(0, 10) ?? '—'}</td>283                </tr>284              ))}285            </tbody>286          </table>287        </div>288        <ul className="mt-3 space-y-1.5 text-[13px]">289          {usedSources.map((s) => (290            <li key={s.slug} className="flex flex-wrap items-center gap-2">291              <Link className="ci-link" href={`/source/${s.slug}`}>292                {s.name}293              </Link>294              <Badge tone={s.license_status === 'approved' ? 'ok' : 'warn'}>license: {s.license_status}</Badge>295              {s.license ? <span className="text-[12px] text-ink-3">{s.license}</span> : null}296            </li>297          ))}298        </ul>299        <Note>300          {standardPop ? `Age-standardized rates use the ${standardPop}; they are not comparable with rates standardized to the World (Segi) population used by IARC. ` : ''}301          Global comparisons are not available: the IARC Global Cancer Observatory remains under license review and the SEER API awaits credentials (CLAUDE.md §10.3-10.4). Only geographies with licensed observations get a page; see <Link className="ci-link" href="/countries">/countries</Link>.302        </Note>303        <Freshness dataUpdatedAt={freshest} sourceVersion={coverage.length ? `${years[years.length - 1]}–${latestYear}` : null} extra={`${fmtInt(coverage.reduce((n, c) => n + c.n_years * c.n_cancers, 0))} observations approx. · ranks from snapshots geo=${scopeCode}`} />304      </Section>305    </article>306  );307}308309function headerLede(geo: { name: string; kind: string }) {310  return `Cancer incidence and mortality for ${geo.name} as published by the source registry: per site group, per year and sex, with age-standardized rates where the source provides them. Population statistics describe groups, never individuals.`;311}312313function toSeries(points: Awaited<ReturnType<typeof trendFor>>): TrendSeries[] {314  const map = new Map<string, TrendSeries>();315  for (const p of points) {316    if (!map.has(p.cancer_id)) map.set(p.cancer_id, { key: p.cancer_id, name: p.canonical_name, href: `/cancer/${p.slug}/statistics`, points: [], dashed: false });317    const s = map.get(p.cancer_id)!;318    s.points.push({ x: Number(p.year), y: Number(p.value) });319    if (p.estimate_type !== 'observed') s.dashed = true;320  }321  // Order series by latest value descending so the legend matches the table.322  return [...map.values()].sort((a, b) => (b.points.at(-1)?.y ?? 0) - (a.points.at(-1)?.y ?? 0));323}324325function TopTable({ t, year, sex, scopeCode, prov, geoSlug }: { t: TopCancersResult; year: number; sex: Sex; scopeCode: string; prov: Awaited<ReturnType<typeof loadProvenance>>; geoSlug: string }) {326  const label = EPI_METRIC_LABEL[t.metric] ?? t.metric;327  const first = t.rows[0];328  const ranked = t.rows.some((r) => r.rank != null);329  const scopeKey = t.year != null ? `geo=${scopeCode}|sex=${sex}|age=all|year=${t.year}|level=top` : null;330  return (331    <Section id={t.metric} kicker="Top cancers" title={`By ${label.toLowerCase()}`} description={t.year == null ? undefined : `${t.year}${t.year !== year ? ` — latest year available for this metric (${year} not yet published by the source)` : ''} · ${sex === 'all' ? 'both sexes' : humanize(sex)} · all ages · ${fmtInt(t.rows.length)} site groups${first?.standard_population ? ` · ${first.standard_population}` : ''}`}>332      {!first ? (333        <EmptyState compact>No {label.toLowerCase()} observation for this geography and sex.</EmptyState>334      ) : (335        <>336          <div className="ci-table-wrap">337            <table className="ci-table">338              <thead>339                <tr>340                  <th className="num">#</th>341                  <th>Cancer</th>342                  <th className="num">343                    {label} ({unitLabel(first.unit)})344                  </th>345                  <th className="num">95% CI</th>346                  <th>Type</th>347                  <th>Rank in scope</th>348                  <th>Site definition</th>349                  <th>Source</th>350                </tr>351              </thead>352              <tbody>353                {t.rows.map((r, i) => (354                  <tr key={r.cancer_id}>355                    <td className="num">{i + 1}</td>356                    <td>357                      <Link className="ci-link" href={`/cancer/${r.slug}`}>358                        {r.canonical_name}359                      </Link>360                      <span className="ml-1.5 text-[11px] text-ink-3">{humanize(r.entity_type)}</span>361                    </td>362                    <td className="num font-medium">{fmtValue(r.value, r.unit)}</td>363                    <td className="num text-ink-3">{r.lower_ci != null && r.upper_ci != null ? `${fmtValue(r.lower_ci, r.unit)}–${fmtValue(r.upper_ci, r.unit)}` : '—'}</td>364                    <td>365                      <Badge tone={r.estimate_type === 'observed' ? 'ok' : 'warn'}>{r.estimate_type}</Badge>366                    </td>367                    <td className="text-[12.5px]">368                      {r.rank != null && r.rank_scope_key ? (369                        <Link className="ci-link" href={`/rankings/${t.metric}?scope=${encodeURIComponent(r.rank_scope_key)}`} title={r.rank_scope_key}>370                          #{r.rank} of {fmtInt(r.eligible_entities)}371                        </Link>372                      ) : (373                        <span className="text-ink-4" title="No current ranking snapshot for this metric and scope">no snapshot</span>374                      )}375                    </td>376                    <td className="max-w-[260px] text-[11.5px] text-ink-3">{r.site_definition ?? '—'}</td>377                    <td>378                      <span className="inline-flex gap-1">379                        <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} compact />380                        <ClaimBadge kind="observed" />381                      </span>382                    </td>383                  </tr>384                ))}385              </tbody>386            </table>387          </div>388          {/* div, not p: the SourceBadge popover holds a <dl>, which would close a <p> in the HTML parser (hydration mismatch). */}389          <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">390            <SourceBadge p={toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }} />391            {ranked && scopeKey ? (392              <span>393                Rank from snapshot <span className="ci-mono">{scopeKey}</span> ·{' '}394                <Link className="ci-link" href={`/rankings/${t.metric}?scope=${encodeURIComponent(scopeKey)}`}>395                  full ranking and "Why this rank?"396                </Link>397              </span>398            ) : (399              <span>"#" is the position in this table; no ranking snapshot covers this scope yet.</span>400            )}401            {t.rows.every((r) => r.estimate_type === 'observed') ? <ConfidenceBadge level="HIGH" /> : <ConfidenceBadge level="MEDIUM" />}402            <Link className="ci-link" href={`/country/${geoSlug}?sex=${sex}&year=${t.year}#trend`}>403              trend ↓404            </Link>405          </div>406          <Freshness dataUpdatedAt={t.rows.map((r) => toDate(r.updated_at)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null} sourceVersion={prov.get(first.provenance_id)?.dataset_version ?? null} />407        </>408      )}409    </Section>410  );411}412